/*
 * Copyright (C) 2021 Huawei Device Co., Ltd.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain (a) copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package io.sentry.ohos.core;

import ohos.aafwk.ability.SystemMemoryInfo;
import ohos.aafwk.content.Intent;
import ohos.agp.window.service.DisplayAttributes;
import ohos.app.Context;
import ohos.batterymanager.BatteryInfo;
import ohos.bundle.AbilityInfo;
import ohos.bundle.BundleInfo;
import ohos.bundle.IBundleManager;
import ohos.data.usage.DataUsage;
import ohos.data.usage.MountState;
import ohos.data.usage.StatVfs;
import ohos.miscservices.timeutility.Time;
import ohos.system.DeviceInfo;
import ohos.system.version.SystemVersion;

import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

import io.sentry.DateUtils;
import io.sentry.EventProcessor;
import io.sentry.ILogger;
import io.sentry.SentryBaseEvent;
import io.sentry.SentryEvent;
import io.sentry.SentryLevel;
import io.sentry.ohos.core.util.ConnectivityChecker;
import io.sentry.ohos.core.util.MainThreadChecker;
import io.sentry.ohos.core.util.RootChecker;
import io.sentry.protocol.App;
import io.sentry.protocol.DebugImage;
import io.sentry.protocol.DebugMeta;
import io.sentry.protocol.Device;
import io.sentry.protocol.OperatingSystem;
import io.sentry.protocol.SentryThread;
import io.sentry.protocol.SentryTransaction;
import io.sentry.protocol.User;
import io.sentry.util.ApplyScopeUtils;
import io.sentry.util.Objects;

/**
 * DefaultOhosEventProcessor
 *
 * @since 2021-06-01
 */
public final class DefaultOhosEventProcessor implements EventProcessor {
    /**
     * PROGUARD_UUID
     */
    @TestOnly
    public static final String PROGUARD_UUID = "proGuardUuids";

    /**
     * ROOTED
     */
    @TestOnly
    public static final String ROOTED = "rooted";

    /**
     * KERNEL_VERSION
     */
    @TestOnly
    public static final String KERNEL_VERSION = "kernelVersion";

    /**
     * EMULATOR
     */
    @TestOnly
    public static final String EMULATOR = "emulator";

    /**
     * SIDE_LOADED
     */
    @TestOnly
    public static final String SIDE_LOADED = "sideLoaded";

    // it could also be (a) parameter and get from Sentry.init(...)
    private static final @Nullable Date APP_START_TIME = DateUtils.getCurrentDateTime();

    /**
     * Context
     */
    @TestOnly
    public final Context context;

    /**
     * Future
     */
    @TestOnly
    public final Future<Map<String, Object>> contextData;

    private final @NotNull IBuildInfoProvider buildInfoProvider;
    private final @NotNull RootChecker rootChecker;

    private final @NotNull ILogger logger;

    /**
     * DefaultOhosEventProcessor
     *
     * @param context Context
     * @param logger ILogger
     * @param buildInfoProvider IBuildInfoProvider
     */
    public DefaultOhosEventProcessor(
        final @NotNull Context context,
        final @NotNull ILogger logger,
        final @NotNull IBuildInfoProvider buildInfoProvider) {
        this(context, logger, buildInfoProvider, new RootChecker(context, buildInfoProvider, logger));
    }

    /**
     * DefaultOhosEventProcessor
     *
     * @param context Context
     * @param logger ILogger
     * @param buildInfoProvider IBuildInfoProvider
     * @param rootChecker RootChecker
     */
    public DefaultOhosEventProcessor(
        final @NotNull Context context,
        final @NotNull ILogger logger,
        final @NotNull IBuildInfoProvider buildInfoProvider,
        final @NotNull RootChecker rootChecker) {
        this.context = Objects.requireNonNull(context, "The application context is required.");
        this.logger = Objects.requireNonNull(logger, "The Logger is required.");
        this.buildInfoProvider =
            Objects.requireNonNull(buildInfoProvider, "The BuildInfoProvider is required.");
        this.rootChecker = Objects.requireNonNull(rootChecker, "The RootChecker is required.");

        ExecutorService executorService = Executors.newSingleThreadExecutor();

        // dont ref. to method reference, theres (a) bug on it
        contextData = executorService.submit(() -> loadContextData());

        executorService.shutdown();
    }

    private @NotNull Map<String, Object> loadContextData() {
        Map<String, Object> map = new HashMap<>();
        String[] proguardUUIDs = getProguardUUIDs();
        if (proguardUUIDs != null) {
            map.put(PROGUARD_UUID, proguardUUIDs);
        }

        map.put(ROOTED, rootChecker.isDeviceRooted());

        String kernelVersion = getKernelVersion();
        if (kernelVersion != null) {
            map.put(KERNEL_VERSION, kernelVersion);
        }

        // its not IO, but it has been cached in the old version as well
        map.put(EMULATOR, isEmulator());

        final Map<String, String> sideLoadedInfo = getSideLoadedInfo();
        if (sideLoadedInfo != null) {
            map.put(SIDE_LOADED, sideLoadedInfo);
        }

        return map;
    }

    @Override
    public @NotNull SentryEvent process(
        final @NotNull SentryEvent event, final @Nullable Object hint) {
        final boolean applyScopeData = shouldApplyScopeData(event, hint);
        if (applyScopeData) {
            // we only set memory data if it's not (a) hard crash, when it's (a) hard crash the event is
            // enriched on restart, so non static data might be wrong, eg lowMemory or availMem will
            // be different if the App. crashes because of OOM.
            processNonCachedEvent(event);
            mergeDebugImages(event);
            setThreads(event);
        }

        setCommons(event, true, applyScopeData);

        return event;
    }

    private void setCommons(
        final @NotNull SentryBaseEvent event,
        final boolean isErrorEvent,
        final boolean isApplyScopeData) {
        mergeUser(event);
        setDevice(event, isErrorEvent, isApplyScopeData);
        mergeOS(event);
        setSideLoadedInfo(event);
    }

    private boolean shouldApplyScopeData(
        final @NotNull SentryBaseEvent event, final @Nullable Object hint) {
        if (ApplyScopeUtils.shouldApplyScopeData(hint)) {
            return true;
        } else {
            logger.log(
                SentryLevel.DEBUG,
                "Event was cached so not applying data relevant to the current app execution/version: %s",
                event.getEventId());
            return false;
        }
    }

    private void mergeUser(final @NotNull SentryBaseEvent event) {
        // userId should be set even if event is Cached as the userId is static and won't change anyway.
        final User user = event.getUser();
        if (user == null) {
            event.setUser(getDefaultUser());
        } else if (user.getId() == null) {
            user.setId(getDeviceId());
        }
    }

    private void setDevice(
        final @NotNull SentryBaseEvent event,
        final boolean isErrorEvent,
        final boolean isApplyScopeData) {
        if (event.getContexts().getDevice() == null) {
            event.getContexts().setDevice(getDevice(isErrorEvent, isApplyScopeData));
        }
    }

    private void mergeOS(final @NotNull SentryBaseEvent event) {
        final OperatingSystem currentOs = event.getContexts().getOperatingSystem();
        final OperatingSystem ohos = getOperatingSystem();

        // make Ohos OS the main OS using the 'os' key
        event.getContexts().setOperatingSystem(ohos);

        if (currentOs != null) {
            // add additional OS which was already part of the SentryEvent (eg Linux read from NDK)
            String osNameKey = currentOs.getName();
            if (osNameKey != null && !osNameKey.isEmpty()) {
                osNameKey = "os_" + osNameKey.trim().toLowerCase(Locale.ROOT);
            } else {
                osNameKey = "os_1";
            }
            event.getContexts().put(osNameKey, currentOs);
        }
    }

    // Data to be applied to events that was created in the running process
    private void processNonCachedEvent(final @NotNull SentryBaseEvent event) {
        App app = event.getContexts().getApp();
        if (app == null) {
            app = new App();
        }
        setAppExtras(app);

        setPackageInfo(event, app);

        event.getContexts().setApp(app);
    }

    private void setThreads(final @NotNull SentryEvent event) {
        if (event.getThreads() != null) {
            for (SentryThread thread : event.getThreads()) {
                thread.setCurrent(MainThreadChecker.isMainThread(thread));
            }
        }
    }

    private void setPackageInfo(final @NotNull SentryBaseEvent event, final @NotNull App app) {
        final BundleInfo bundleInfo = ContextUtils.getPackageInfo(context, logger);
        if (bundleInfo != null) {
            String versionCode = ContextUtils.getVersionCode(bundleInfo);

            setDist(event, versionCode);
            setAppPackageInfo(app, bundleInfo);
        }
    }

    private void setDist(final @NotNull SentryBaseEvent event, final @NotNull String versionCode) {
        if (event.getDist() == null) {
            event.setDist(versionCode);
        }
    }

    private void mergeDebugImages(final @NotNull SentryEvent event) {
        final List<DebugImage> debugImages = getDebugImages();
        if (debugImages == null) {
            return;
        }

        DebugMeta debugMeta = event.getDebugMeta();

        if (debugMeta == null) {
            debugMeta = new DebugMeta();
        }

        // sets the imageList or append to the list if it already exists
        if (debugMeta.getImages() == null) {
            debugMeta.setImages(debugImages);
        } else {
            debugMeta.getImages().addAll(debugImages);
        }
        event.setDebugMeta(debugMeta);
    }

    private @Nullable List<DebugImage> getDebugImages() {
        String[] proguardUUIDs = null;
        try {
            Object proguardUUIDsObject = contextData.get().get(PROGUARD_UUID);
            if (proguardUUIDsObject != null) {
                proguardUUIDs = (String[]) proguardUUIDsObject;
            }
        } catch (Exception e) {
            logger.log(SentryLevel.ERROR, "Error getting Proguard UUIDs.", e);
            return null;
        }

        if (proguardUUIDs == null || proguardUUIDs.length == 0) {
            return null;
        }

        List<DebugImage> images = new ArrayList<>();

        for (String item : proguardUUIDs) {
            DebugImage debugImage = new DebugImage();
            debugImage.setType("proguard");
            debugImage.setUuid(item);
            images.add(debugImage);
        }

        return images;
    }

    private void setAppExtras(final @NotNull App app) {
        app.setAppName(getApplicationName());
        app.setAppStartTime(APP_START_TIME);
    }

    @SuppressWarnings("deprecation")
    private @NotNull String getAbi() {
        return "";
    }

    @SuppressWarnings("deprecation")
    private @NotNull String getAbi2() {
        return "";
    }

    @SuppressWarnings({"ObsoleteSdkInt", "deprecation"})
    private void setArchitectures(final @NotNull Device device) {
        device.setArchs(null);
    }

    @SuppressWarnings("ObsoleteSdkInt")
    private @NotNull Long getMemorySize(final @NotNull SystemMemoryInfo memInfo) {
        // using Runtime as (a) fallback
        if (memInfo != null) {
            return memInfo.getTotalSysMem();
        }
        return Runtime.getRuntime().totalMemory(); // JVM in bytes too
    }

    // we can get some inspiration here
    private @NotNull Device getDevice(final boolean isErrorEvent, final boolean isApplyScopeData) {
        // TODO: missing usable memory

        Device device = new Device();
        device.setName(getDeviceName());
        device.setFamily(getFamily());
        setArchitectures(device);

        // setting such values require IO hence we don't run for transactions
        if (isErrorEvent) {
            setDeviceIO(device, isApplyScopeData);
        }

        device.setOrientation(getOrientation());

        try {
            Object emulator = contextData.get().get(EMULATOR);
            if (emulator != null) {
                device.setSimulator((Boolean) emulator);
            }
        } catch (Exception e) {
            logger.log(SentryLevel.ERROR, "Error getting emulator.", e);
        }

        DisplayAttributes displayMetrics = getDisplayMetrics();
        if (displayMetrics != null) {
            device.setScreenWidthPixels(displayMetrics.width);
            device.setScreenHeightPixels(displayMetrics.height);
            device.setScreenDensity(displayMetrics.scalDensity);
            device.setScreenDpi(displayMetrics.densityDpi);
        }

        device.setBootTime(getBootTime());
        device.setTimezone(getTimeZone());

        if (device.getId() == null) {
            device.setId(getDeviceId());
        }
        if (device.getLanguage() == null) {
            device.setLanguage(Locale.getDefault().toString()); // eg en_US
        }

        return device;
    }

    private void setDeviceIO(final @NotNull Device device, final boolean applyScopeData) {
        final Intent batteryIntent = getBatteryIntent();
        if (batteryIntent != null) {
            device.setBatteryLevel(getBatteryLevel(batteryIntent));
            device.setCharging(isCharging(batteryIntent));
            device.setBatteryTemperature(getBatteryTemperature(batteryIntent));
        }

        Boolean connected;
        switch (ConnectivityChecker.getConnectionStatus(context, logger)) {
            case NOT_CONNECTED:
                connected = false;
                break;
            case CONNECTED:
                connected = true;
                break;
            default:
                connected = null;
        }
        device.setOnline(connected);

        final SystemMemoryInfo memInfo = getMemInfo();
        if (memInfo != null) {
            // in bytes
            device.setMemorySize(getMemorySize(memInfo));
            if (applyScopeData) {
                // device.setFreeMemory(memInfo.availMem);
                // device.setLowMemory(memInfo.lowMemory);
            }
        }

        // this way of getting the size of storage might be problematic for storages bigger than 2GB
        final File internalStorageFile = context.getExternalFilesDir(null);
        if (internalStorageFile != null) {
            StatVfs internalStorageStat = new StatVfs(internalStorageFile.getPath());
            device.setStorageSize(getTotalInternalStorage(internalStorageStat));
            device.setFreeStorage(getUnusedInternalStorage(internalStorageStat));
        }

        final StatVfs externalStorageStat = getExternalStorageStat(internalStorageFile);
        if (externalStorageStat != null) {
            device.setExternalStorageSize(getTotalExternalStorage(externalStorageStat));
            device.setExternalFreeStorage(getUnusedExternalStorage(externalStorageStat));
        }

        if (device.getConnectionType() == null) {
            // wifi, ethernet or cellular, null if none
            device.setConnectionType(
                ConnectivityChecker.getConnectionType(context, logger, buildInfoProvider));
        }
    }

    @SuppressWarnings("ObsoleteSdkInt")
    private @Nullable String getDeviceName() {
        return DeviceInfo.getName();
    }

    private TimeZone getTimeZone() {
        Locale firstLocale = context.getResourceManager().getConfiguration().getFirstLocale();
        if (firstLocale != null) {
            return Calendar.getInstance(firstLocale).getTimeZone();
        }
        return Calendar.getInstance().getTimeZone();
    }

    @SuppressWarnings("JdkObsolete")
    private @Nullable Date getBootTime() {
        try {
            // if user changes the clock, will give a wrong answer, consider ACTION_TIME_CHANGED.
            // currentTimeMillis returns UTC already

            return DateUtils.getDateTime(Time.getRealActiveTime());
        } catch (IllegalArgumentException e) {
            logger.log(SentryLevel.ERROR, e, "Error getting the device's boot time.");
        }
        return null;
    }

    /**
     * Get MemoryInfo object representing the memory state of the application.
     *
     * @return MemoryInfo object representing the memory state of the application
     */
    private @Nullable SystemMemoryInfo getMemInfo() {
        return new SystemMemoryInfo();
    }

    private @Nullable Intent getBatteryIntent() {
        return new Intent();
    }

    /**
     * Fake the device family by using the first word in the Build.MODEL. Works well in most cases...
     *
     * @return family name of the device, as best we can tell
     */
    private @Nullable String getFamily() {
        return null;
    }

    /**
     * Get the device's current battery level (as a percentage of total).
     *
     * @param batteryIntent intent for get battery info
     * @return the device's current battery level (as a percentage of total), or null if unknown
     */
    private @Nullable Float getBatteryLevel(final @NotNull Intent batteryIntent) {
        return new Float(new BatteryInfo().getCapacity());
    }

    /**
     * Checks whether or not the device is currently plugged in and charging, or null if unknown.
     *
     * @param batteryIntent battery intent
     * @return whether or not the device is currently plugged in and charging, or null if unknown
     */
    private @Nullable Boolean isCharging(final @NotNull Intent batteryIntent) {
        BatteryInfo batteryInfo = new BatteryInfo();
        int ordinal = batteryInfo.getChargingStatus().ordinal();
        return ordinal == BatteryInfo.BatteryChargeState.ENABLE.ordinal()
            || ordinal == BatteryInfo.BatteryChargeState.FULL.ordinal();
    }

    private @Nullable Float getBatteryTemperature(final @NotNull Intent batteryIntent) {
        BatteryInfo batteryInfo = new BatteryInfo();
        int batteryTemperature = batteryInfo.getBatteryTemperature();
        return new Float(batteryTemperature);
    }

    /**
     * Get the device's current screen orientation.
     *
     * @return the device's current screen orientation, or null if unknown
     */
    @SuppressWarnings("deprecation")
    private @Nullable Device.DeviceOrientation getOrientation() {
        AbilityInfo.DisplayOrientation orientation = context.getAbilityInfo().getOrientation();
        if (orientation == AbilityInfo.DisplayOrientation.LANDSCAPE) {
            return Device.DeviceOrientation.LANDSCAPE;
        } else if (orientation == AbilityInfo.DisplayOrientation.PORTRAIT) {
            return Device.DeviceOrientation.PORTRAIT;
        } else {
            logger.log(
                SentryLevel.INFO,
                "No device orientation available (ORIENTATION_SQUARE|ORIENTATION_UNDEFINED)");
            return null;
        }
    }

    private @Nullable Boolean isEmulator() {
        return false;
    }

    /**
     * Get the total amount of internal storage, in bytes.
     *
     * @param stat state for get internal total storage
     * @return the total amount of internal storage, in bytes
     */
    private @Nullable Long getTotalInternalStorage(final @NotNull StatVfs stat) {
        return stat.getSpace();
    }

    @SuppressWarnings("deprecation")
    private long getBlockSizeDep(final @NotNull StatVfs stat) {
        return stat.getSpace();
    }

    /**
     * Get the unused amount of internal storage, in bytes.
     *
     * @param stat for get unused internal storage
     * @return the unused amount of internal storage, in bytes
     */
    private @Nullable Long getUnusedInternalStorage(final @NotNull StatVfs stat) {
        return stat.getAvailableSpace();
    }

    private @Nullable StatVfs getExternalStorageStat(final @Nullable File internalStorage) {
        if (!isExternalStorageMounted()) {
            File path = getExternalStorageDep(internalStorage);
            if (path != null) { // && path.canRead()) { canRead() will read return false
                return new StatVfs(path.getPath());
            }
            logger.log(SentryLevel.INFO, "Not possible to read external files directory");
            return null;
        }
        logger.log(SentryLevel.INFO, "External storage is not mounted or emulated.");
        return null;
    }

    @SuppressWarnings("ObsoleteSdkInt")
    private @Nullable File[] getExternalFilesDirs() {
        return context.getExternalFilesDirs(null);
    }

    private @Nullable File getExternalStorageDep(final @Nullable File internalStorage) {
        File[] externalFilesDirs = getExternalFilesDirs();

        if (externalFilesDirs != null) {
            // return the 1st file which is not the emulated internal storage
            String internalStoragePath = null;

            try {
                internalStoragePath = internalStorage != null ? internalStorage.getCanonicalPath() : null;
            } catch (IOException e) {
                e.printStackTrace();
            }
            for (File file : externalFilesDirs) {
                // externalFilesDirs may contain null values :(
                if (file == null) {
                    continue;
                }

                // return the 1st file if you cannot compare with the internal one
                if (internalStoragePath == null || internalStoragePath.isEmpty()) {
                    return file;
                }
                // if we are looking to the same directory, let's check the next one or no external storage
                try {
                    if (file.getCanonicalPath().contains(internalStoragePath)) {
                        continue;
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                return file;
            }
        } else {
            logger.log(SentryLevel.INFO, "Not possible to read getExternalFilesDirs");
        }
        return null;
    }

    /**
     * Get the total amount of external storage, in bytes, or null if no external storage is mounted.
     *
     * @param stat for get total external storage
     * @return the total amount of external storage, in bytes, or null if no external storage is
     * mounted
     */
    private @Nullable Long getTotalExternalStorage(final @NotNull StatVfs stat) {
        return stat.getSpace();
    }

    private boolean isExternalStorageMounted() {
        return DataUsage.getDiskMountedStatus().equals(MountState.DISK_MOUNTED);
    }

    /**
     * Get the unused amount of external storage, in bytes, or null if no external storage is mounted.
     *
     * @param stat for get unused external storage
     * @return the unused amount of external storage, in bytes, or null if no external storage is
     * mounted
     */
    private @Nullable Long getUnusedExternalStorage(final @NotNull StatVfs stat) {
        return stat.getAvailableSpace();
    }

    /**
     * Get the DisplayAttributes object for the current application.
     *
     * @return the DisplayAttributes object for the current application
     */
    private @Nullable DisplayAttributes getDisplayMetrics() {
        try {
            return new DisplayAttributes();
        } catch (Exception e) {
            logger.log(SentryLevel.ERROR, "Error getting DisplayMetrics.", e);
            return null;
        }
    }

    private @NotNull OperatingSystem getOperatingSystem() {
        OperatingSystem os = new OperatingSystem();
        os.setName("Ohos");
        os.setVersion(SystemVersion.getVersion());

        try {
            Object kernelVersion = contextData.get().get(KERNEL_VERSION);
            if (kernelVersion != null) {
                os.setKernelVersion((String) kernelVersion);
            }

            Object rooted = contextData.get().get(ROOTED);
            if (rooted != null) {
                os.setRooted((Boolean) rooted);
            }
        } catch (Exception e) {
            logger.log(SentryLevel.ERROR, "Error getting OperatingSystem.", e);
        }

        return os;
    }

    private void setAppPackageInfo(final @NotNull App app, final @NotNull BundleInfo bundleInfo) {
        app.setAppIdentifier(bundleInfo.getName());
        app.setAppVersion(bundleInfo.getVersionName());
        app.setAppBuild(ContextUtils.getVersionCode(bundleInfo));
    }

    /**
     * Get the device's current kernel version, as a string. Attempts to read /proc/version, and falls
     * back to the 'os.version' System Property.
     *
     * @return the device's current kernel version, as a string
     */
    @SuppressWarnings("DefaultCharset")
    private @Nullable String getKernelVersion() {
        // its possible to try to execute 'uname' and parse it or also another unix commands or even
        // looking for well known root installed apps
        String errorMsg = "Exception while attempting to read kernel information";
        String defaultVersion = System.getProperty("os.version");

        File file = new File("/proc/version");
        if (!file.canRead()) {
            return defaultVersion;
        }
        try (BufferedReader br = new BufferedReader(new FileReader(file))) {
            return br.readLine();
        } catch (IOException e) {
            logger.log(SentryLevel.ERROR, errorMsg, e);
        }

        return defaultVersion;
    }

    /**
     * Get the human-facing Application name.
     *
     * @return Application name
     */
    private @Nullable String getApplicationName() {
        return context.getApplicationInfo().getLabel();
    }

    /**
     * Sets the default user which contains only the userId.
     *
     * @return the User object
     */
    public @NotNull User getDefaultUser() {
        User user = new User();
        user.setId(getDeviceId());

        return user;
    }

    private @Nullable String getDeviceId() {
        try {
            return Installation.id(context);
        } catch (Exception e) {
            logger.log(SentryLevel.ERROR, "Error getting installationId.", e);
        }
        return null;
    }

    private @Nullable String[] getProguardUUIDs() {
        return null;
    }

    @SuppressWarnings("deprecation")
    private @Nullable Map<String, String> getSideLoadedInfo() {
        String packageName = null;

        try {
            final BundleInfo bundleInfo = ContextUtils.getPackageInfo(context, logger);
            final IBundleManager bundleManager = context.getBundleManager();
            if (bundleInfo != null && bundleManager != null) {
                packageName = bundleInfo.getName();

                // getInstallSourceInfo requires INSTALL_PACKAGES permission which is only given to system
                // apps.
                final String installerPackageName = packageName;

                final Map<String, String> sideLoadedInfo = new HashMap<>();

                if (installerPackageName != null) {
                    sideLoadedInfo.put("isSideLoaded", "false");

                    // could be amazon, google play etc
                    sideLoadedInfo.put("installerStore", installerPackageName);
                } else {
                    // if it's installed via adb, system apps or untrusted sources
                    sideLoadedInfo.put("isSideLoaded", "true");
                }

                return sideLoadedInfo;
            }
        } catch (IllegalArgumentException e) {
            // it'll never be thrown as we are querying its own App's package.
            logger.log(SentryLevel.DEBUG, "%s package isn't installed.", packageName);
        }

        return null;
    }

    @SuppressWarnings("unchecked")
    private void setSideLoadedInfo(final @NotNull SentryBaseEvent event) {
        try {
            final Object sideLoadedInfo = contextData.get().get(SIDE_LOADED);

            if (sideLoadedInfo instanceof Map) {
                for (final Map.Entry<String, String> entry
                    : ((Map<String, String>) sideLoadedInfo).entrySet()) {
                    event.setTag(entry.getKey(), entry.getValue());
                }
            }
        } catch (Exception e) {
            logger.log(SentryLevel.ERROR, "Error getting side loaded info.", e);
        }
    }

    @Override
    public @NotNull SentryTransaction process(
        final @NotNull SentryTransaction transaction, final @Nullable Object hint) {
        final boolean applyScopeData = shouldApplyScopeData(transaction, hint);

        if (applyScopeData) {
            processNonCachedEvent(transaction);
        }

        setCommons(transaction, false, applyScopeData);

        return transaction;
    }
}
